WebブラウザからAmazon S3に直接ファイルをアップロードする – AWS SDK for Javaを使う
AWS SDK for Javaを利用して実装
先日、WebブラウザからAmazon S3に直接ファイルをアップロードするという記事を公開しましたが、この記事中でAWS SDKで実装されている処理をわざわざ自前で実装してしまっていたことに気がついたので、補足のエントリを書きます。
自前で実装してしまっていたのは、S3のREST APIにアクセスする際の認証情報を含んだURLの生成部分です。この処理は、AWS SDK for Javaのcom.amazonaws.services.s3.AmazonS3Client#generatePresignedUrlメソッドで実装されていました。記事公開前にもSDKに実装されていないか調べてはいたのですが、どうやら見落としていたようです。
自前で実装しても高々100行位のものではありますが、SDKで提供されているのであればそちらを利用するに越した事はありません。ということで、前回の記事のサンプルアプリで認証情報を含んだURLの生成を実装していた部分をAWS SDKのAPIに置き換えて実装し直してみました。
開発環境
- AWS SDK for Java 1.4.1
他は前回と同じです。
ビルドの設定
AWS SDK for Javaを利用したいので、sbtのライブラリの依存性の設定を変更します。Mavenリポジトリにホストされているので、resolverは追加しないでOKです。
build.scala
libraryDependencies ++= Seq( "org.scalatra" %% "scalatra" % ScalatraVersion, "org.scalatra" %% "scalatra-scalate" % ScalatraVersion, "org.scalatra" %% "scalatra-json" % ScalatraVersion, "org.scalatra" %% "scalatra-specs2" % ScalatraVersion % "test", "org.json4s" %% "json4s-jackson" % "3.1.0", "com.amazonaws" % "aws-java-sdk" % "1.4.1", "ch.qos.logback" % "logback-classic" % "1.0.6" % "runtime", "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container", "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" % "container;provided;test" artifacts (Artifact("javax.servlet", "jar", "jar")) ),
アプリケーションサーバ側の実装
今回実装し直したのは、アプリケーションサーバ側の認証情報を含んだURLの生成部分と、そのURLを取得するためのREST API部分です。以下、ソースコードです。
SignController.scala
package jp.classmethod.s3corstest import org.scalatra._ import org.scalatra.json._ import org.json4s._ import jp.classmethod.s3corstest.helpers._ import grizzled.slf4j.Logger class SignController extends ScalatraServlet with JacksonJsonSupport { import SignController._ private[this] lazy val logger = Logger(getClass) override protected implicit val jsonFormats = DefaultFormats before() { contentType = formats("json") } get("/put") { logger info "GET /sign/put" val fileInfo = for { objectName <- params.getAs[String]("name") mimeType <- params.getAs[String]("type") } yield S3FileInfo(targetBucketName, objectName, Some(mimeType)) val fileInfoEither = fileInfo match { case Some(s3FileInfo: S3FileInfo) => Right(s3FileInfo) case None => Left(new IllegalStateException("invalid params.")) } val result = fileInfoEither.right flatMap { val s3SignHelper = new S3SignHelper s3SignHelper.putS3ObjectUrl(_) } result match { case Right(url: String) => Ok(Map("url" -> url)) case Left(e: Exception) => halt(400, e.getMessage) } } } object SignController { private val targetBucketName = "classmethod-s3-cors-test" }
AWSHelper.scala
package jp.classmethod.s3corstest.helpers import java.util.Date import java.net.URLEncoder import com.amazonaws.services.s3.AmazonS3Client import com.amazonaws.{HttpMethod, AmazonClientException} import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest class S3SignHelper extends S3URLProvider with AWSCredentialsPropertiesSupport object S3URLProvider { private val expireSeconds = 300 } trait S3URLProvider { self: AWSCredentialsSupport => import S3URLProvider._ def putS3ObjectUrl( fileInfo: S3FileInfo, requestHeaderMap: Map[String, String]): Either[Exception, String] = s3ObjectUrl(HttpMethod.PUT, fileInfo, requestHeaderMap) def putS3ObjectUrl(fileInfo: S3FileInfo): Either[Exception, String] = putS3ObjectUrl(fileInfo, Map.empty[String, String]) private[this] def s3ObjectUrl( httpMethod: HttpMethod, fileInfo: S3FileInfo, requestHeaderMap: Map[String, String]): Either[Exception, String] = { require(fileInfo != null) val s3Client = new AmazonS3Client(credentials) val request = new GeneratePresignedUrlRequest(fileInfo.bucketName, fileInfo.fileName, httpMethod) request.setExpiration(new Date(new Date().getTime + expireSeconds * 1000)) fileInfo.mimeType foreach { request.setContentType(_) } for ((key, value) <- requestHeaderMap) request.addRequestParameter(key, value) try { val url = s3Client.generatePresignedUrl(request) Right(URLEncoder.encode(url.toString, "UTF-8")) } catch { case e: AmazonClientException => Left(e) } } } case class S3FileInfo(bucketName: String, fileName: String, mimeType: Option[String])
AWSCredentials.scala
package jp.classmethod.s3corstest.helpers import java.io.File import com.amazonaws.auth.{AWSCredentials, PropertiesCredentials} trait AWSCredentialsSupport { protected def credentials: AWSCredentials } trait AWSCredentialsPropertiesSupport extends AWSCredentialsSupport { protected def credentials = AWSCredentialsPropertiesSupport.credentials } object AWSCredentialsPropertiesSupport { private lazy val credentials: AWSCredentials = new PropertiesCredentials(new File("credentials.properties")) }
認証情報を含んだURLの生成
アプリケーションサーバ側の認証情報を含んだURLの生成部分のソースコードです。
val s3Client = new AmazonS3Client(credentials) // URL作成対象のS3オブジェクトの情報を設定 val request = new GeneratePresignedUrlRequest(fileInfo.bucketName, fileInfo.fileName, httpMethod) // 認証情報の失効日時を設定 request.setExpiration(new Date(new Date().getTime + expireSeconds * 1000)) // Content-Typeを設定 fileInfo.mimeType foreach { request.setContentType(_) } // AWSのカスタムリクエストヘッダを設定 for ((key, value) <- requestHeaderMap) request.addRequestParameter(key, value) try { // 認証情報を含んだURLを生成 val url = s3Client.generatePresignedUrl(request) Right(URLEncoder.encode(url.toString, "UTF-8")) } catch { case e: AmazonClientException => Left(e) }
13行目のAmazonS3Client#generatePresignedUrlメソッドで認証情報を含んだURLを生成しています。このメソッドの戻り値は、java.net.URL型です。メソッドの引数として、アクセスするS3オブジェクトのバケット名やオブジェクト名等の各種情報をセットしたGeneratePresignedUrlRequest型のインスタンスを渡しています。認証情報の失効日時やContent-Typeなどの情報も、このインスタンスにセットしています。
なお、生成されたURLはURLエンコードされていませんので、URLエンコードしてからクライアントに返す必要があります。
まとめ
AWS SDKを利用することで、アプリケーションサーバ側の実装もとても簡単になりました。ただし、注意点もあります。Signatureの不一致による認証の失敗時には、エラー情報としてS3からSignatureを生成する過程で作成される署名対象の文字列が返されますが、SDKを利用していると署名対象の文字列がなんであったかは分からないため問題の切り分けがしづらくなります。この場合は、S3のクエリストリング方式の認証の仕様を把握していると署名対象の文字列が推測できるため、問題が解決しやすくなると思います。